/*
 * Copyright (c) 2025 Huawei Device Co., Ltd.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { ArkFile, AstTreeUtils, ts } from "arkanalyzer/lib";
import { BaseChecker, BaseMetaData } from "../BaseChecker";
import { Defects, IssueReport } from "../../model/Defects";
import { MatcherCallback, MatcherTypes, MethodMatcher, FileMatcher } from "../../matcher/Matchers";
import { Rule } from "../../model/Rule";
import { RuleListUtil } from "../../utils/common/DefectsList";
import { RuleFix } from '../../model/Fix';

interface Options {
    allowAny?: boolean;
    allowNullableBoolean?: boolean;
    allowNullableEnum?: boolean;
    allowNullableNumber?: boolean;
    allowNullableObject?: boolean;
    allowNullableString?: boolean;
    allowNumber?: boolean;
    allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing?: boolean;
    allowString?: boolean;
};

interface Issue {
    ruleFix: RuleFix;
    line: number;
    column: number;
    message: string;
    filePath: string;
}

type VariantType =
    | 'any'
    | 'boolean'
    | 'enum'
    | 'never'
    | 'nullish'
    | 'number'
    | 'object'
    | 'string'
    | 'truthy boolean'
    | 'truthy number'
    | 'truthy string'
    | 'nullable boolean'
    | 'nullable string'
    | 'nullable number'
    | 'unknown';

export class StrictBooleanExpressionsCheck implements BaseChecker {
    public rule: Rule;
    private options: Options;
    public defects: Defects[] = [];
    public issues: IssueReport[] = [];
    private traversedNodes = new Set<ts.Node>();
    private defaultOptions: Options = {
        allowString: true,
        allowNumber: true,
        allowNullableObject: true,
        allowNullableBoolean: false,
        allowNullableString: false,
        allowNullableNumber: false,
        allowNullableEnum: false,
        allowAny: false,
        allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing: false,
    };
    private typeChecker: ts.TypeChecker | undefined;

    constructor() {
        this.options = this.defaultOptions;
    }

    registerMatchers(): MatcherCallback[] {
        return [{ matcher: this.fileMatcher, callback: this.check }];
    }

    public metaData: BaseMetaData = {
        severity: 1,
        ruleDocPath: 'docs/strict-boolean-expressions-check.md',
        description: 'Disallow certain types in boolean expressions'
    };

    private fileMatcher: FileMatcher = {
        matcherType: MatcherTypes.FILE
    };

    public check = (target: ArkFile) => {
        if (this.rule && this.rule.option && this.rule.option.length > 0) {
            this.options = this.rule.option[0] as Options;
        }
        if (target instanceof ArkFile) {
            const sourceFile = AstTreeUtils.getASTNode(target.getName(), target.getCode());
            this.typeChecker = this.createTypeChecker(sourceFile);
            const code = target.getCode();
            this.checkBooleanExpressionsStrictly(sourceFile).forEach((issue) => {
                issue.filePath = target.getFilePath();
                this.addIssueReport(issue);
            });
        }
    }

    private createTypeChecker(sourceFile: ts.SourceFile): ts.TypeChecker {
        const compilerOptions: ts.CompilerOptions = {
            target: ts.ScriptTarget.Latest,
            module: ts.ModuleKind.CommonJS,
            strict: true,
            noImplicitAny: true,
            allowJs: true,
            checkJs: true,
            declaration: true,
            types: ['node'],
            allowDeclarationFiles: true
        };

        const compilerHost = ts.createCompilerHost(compilerOptions);
        const defaultLibPath = ts.getDefaultLibFilePath(compilerOptions);

        const program = ts.createProgram({
            rootNames: [sourceFile.fileName, defaultLibPath],
            options: compilerOptions,
            host: compilerHost
        });

        return program.getTypeChecker();
    }

    private checkBooleanExpressionsStrictly(sourceFile: ts.SourceFile): Issue[] {
        const issues: Issue[] = [];
        this.traversedNodes.clear();
        const declarePattern = /^\s*declare\s+const\s+\w+\s*:\s*(boolean|string|number|null\s*\|\s*object)\s*;\s*$/;
        const checkNode = (node: ts.Node): void => {
            if (this.traversedNodes.has(node)) return;
            this.traversedNodes.add(node);

            if (ts.isConditionalExpression(node) ||
                ts.isIfStatement(node) ||
                ts.isWhileStatement(node)) {
                const condition = ts.isConditionalExpression(node) ?
                    node.condition : node.expression;

                if (condition) {
                    const { line: startLine } = sourceFile.getLineAndCharacterOfPosition(condition.getStart());
                    const lineText = sourceFile.getFullText().split('\n')[startLine];

                    const prevLineText = startLine > 0 ? sourceFile.getFullText().split('\n')[startLine - 1] : '';

                    if (declarePattern.test(prevLineText) &&
                        /^\s*if\s*\(\w+\)\s*{\s*}\s*$/.test(lineText)) {
                        return;
                    }

                    if (ts.isBinaryExpression(condition) && condition.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) {
                        return;
                    }

                    let targetNode = condition;
                    if (ts.isPrefixUnaryExpression(condition) && condition.operator === ts.SyntaxKind.ExclamationToken) {
                        targetNode = condition.operand;
                    }

                    if (ts.isIdentifier(targetNode)) {
                        const symbol = this.typeChecker?.getSymbolAtLocation(targetNode);
                        if (symbol?.declarations?.[0]) {
                            const declaration = symbol.declarations[0];
                            if (ts.isVariableDeclaration(declaration) && declaration.type) {
                                const declaredType = declaration.type;

                                if (ts.isTypeReferenceNode(declaredType)) {
                                    const typeName = declaredType.typeName.getText();
                                    if (typeName === 'boolean' ||
                                        (typeName === 'string' && this.options.allowString) ||
                                        (typeName === 'number' && this.options.allowNumber)) {
                                        return;
                                    }
                                    if (typeName === 'object' && this.options.allowNullableObject) {
                                        return;
                                    }
                                }
                            }
                        }
                    }

                    const type = this.getNodeType(targetNode);
                    if (!type) return;

                    if (ts.isLiteralExpression(targetNode)) {
                        if (ts.isStringLiteral(targetNode) && this.options.allowString) return;
                        if (ts.isNumericLiteral(targetNode) && this.options.allowNumber) return;
                        if (targetNode.kind === ts.SyntaxKind.TrueKeyword ||
                            targetNode.kind === ts.SyntaxKind.FalseKeyword) return;
                    }
                    const variantTypes = this.inspectVariantTypes([type]);
                    const reportType = this.determineReportType(variantTypes);
                    if (reportType) {
                        if (!(reportType === 'nullable boolean' && ts.isBinaryExpression(condition) &&
                            condition.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)) {
                            const pos = condition.getStart();
                            const end = condition.getEnd();
                            let fixText = '';
                            switch (reportType) {
                                case 'any':
                                    fixText = `Boolean(${condition.getText()})`;
                                    break;
                                case 'nullish':
                                case 'nullable boolean':
                                case 'nullable string':
                                case 'nullable number':
                                    fixText = `${condition.getText()} != null`;
                                    break;
                                case 'object':
                                    fixText = `${condition.getText()} !== null`;
                                    break;
                                default:
                                    fixText = condition.getText();
                            }
                            const fix: RuleFix = { range: [pos, end], text: fixText };
                            this.addIssueToList(fix, condition, sourceFile, reportType, issues);
                        }
                    }
                }
            }

            ts.forEachChild(node, checkNode);
        };

        checkNode(sourceFile);
        return issues;
    }

    private determineReportType(variantTypes: Set<VariantType>): string | undefined {
        if (variantTypes.has('any') && !this.options.allowAny) return 'any';
        if (variantTypes.has('nullish')) return 'nullish';
        if (variantTypes.has('nullable boolean') && !this.options.allowNullableBoolean) return 'nullable boolean';
        if (variantTypes.has('nullable string') && !this.options.allowNullableString) return 'nullable string';
        if (variantTypes.has('nullable number') && !this.options.allowNullableNumber) return 'nullable number';
        if (variantTypes.has('object')) return 'object';
        return undefined;
    }

    private inspectVariantTypes(types: ts.Type[]): Set<VariantType> {
        const variantTypes = new Set<VariantType>();

        const flattenedTypes = types.reduce((acc: ts.Type[], type) => {
            if (type.isUnion()) {
                acc.push(...type.types);
            } else {
                acc.push(type);
            }
            return acc;
        }, []);

        const hasNullish = flattenedTypes.some(type =>
            type.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.VoidLike)
        );

        const nonNullishTypes = flattenedTypes.filter(type =>
            !(type.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.VoidLike))
        );

        const typeFlags = nonNullishTypes.reduce((flags, type) => flags | type.flags, 0);
        const isBoolean = Boolean(typeFlags & ts.TypeFlags.Boolean || typeFlags & ts.TypeFlags.BooleanLiteral);
        const isString = Boolean(typeFlags & ts.TypeFlags.String || typeFlags & ts.TypeFlags.StringLiteral);
        const isNumber = Boolean(typeFlags & ts.TypeFlags.Number || typeFlags & ts.TypeFlags.NumberLiteral);
        const isObject = Boolean(typeFlags & ts.TypeFlags.Object);

        if (isBoolean) {
            variantTypes.add('boolean');
            return variantTypes;
        }

        if (isString && this.options.allowString) {
            variantTypes.add('string');
            return variantTypes;
        }

        if (isNumber && this.options.allowNumber) {
            variantTypes.add('number');
            return variantTypes;
        }

        if (isObject) {
            if (hasNullish && this.options.allowNullableObject) {
                return variantTypes;
            }
            variantTypes.add('object');
        }

        if (hasNullish) {
            variantTypes.add('nullish');
        }

        if (typeFlags & ts.TypeFlags.Any) {
            const isKnownType = isBoolean || isString || isNumber || isObject;
            if (!isKnownType) {
                variantTypes.add('any');
            }
        }

        return variantTypes;
    }

    private getNodeType(node: ts.Node): ts.Type | undefined {
        if (!this.typeChecker) return undefined;
        try {
            const type = this.typeChecker.getTypeAtLocation(node);
            return type;
        } catch {
            return undefined;
        }
    }

    private getErrorMessage(type: string): string {
        const messages: { [key: string]: string } = {
            'nullish': 'Unexpected nullish value in conditional. The condition is always false.',
            'nullable boolean': 'Unexpected nullable boolean value in conditional. Please handle the nullish case explicitly.',
            'nullable string': 'Unexpected nullable string value in conditional.',
            'nullable number': 'Unexpected nullable number value in conditional.',
            'object': 'Unexpected object value in conditional. The condition is always true.',
            'any': 'Unexpected any value in conditional. An explicit comparison or type cast is required.'
        };
        return messages[type] || 'Unexpected value in condition';
    }

    private addIssueToList(ruleFix: RuleFix, node: ts.Node, sourceFile: ts.SourceFile, type: string, issues: Issue[]): void {
        let targetNode = node;
        let actualStart = node.getStart(sourceFile);
        if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
            targetNode = node.operand;
            actualStart = targetNode.getStart(sourceFile);
        } else if (ts.isConditionalExpression(node)) {
            targetNode = node.condition;
            actualStart = targetNode.getStart(sourceFile);
        }
        const { line, character } = sourceFile.getLineAndCharacterOfPosition(actualStart);
        const lineText = sourceFile.getFullText().split('\n')[line];
        let actualColumn;
        if (ts.isConditionalExpression(node)) {
            const conditionText = targetNode.getText();
            const conditionPos = lineText.indexOf(conditionText);
            actualColumn = conditionPos + 1;
        } else {
            actualColumn = character + 1;
        }
        issues.push({
            ruleFix: ruleFix,
            line: line + 1,
            column: actualColumn,
            message: this.getErrorMessage(type),
            filePath: ''
        });
    }

    private addIssueReport(issue: Issue) {
        this.metaData.description = issue.message;
        const severity = this.rule.alert ?? this.metaData.severity;
        const defect = new Defects(issue.line, issue.column, issue.column, this.metaData.description, severity,
            this.rule.ruleId, issue.filePath, this.metaData.ruleDocPath, true, false, false);
        RuleListUtil.push(defect);
        const fix: RuleFix = issue.ruleFix;
        let issueReport: IssueReport = { defect, fix };
        this.issues.push(issueReport);
    }
}